a tool for shared writing and social publishing
at debug/datetime 595 lines 17 kB view raw
1"use client"; 2import { Agent, AppBskyRichtextFacet, UnicodeString } from "@atproto/api"; 3import { 4 useState, 5 useCallback, 6 useRef, 7 useLayoutEffect, 8 useEffect, 9} from "react"; 10import { createPortal } from "react-dom"; 11import { useDebouncedEffect } from "src/hooks/useDebouncedEffect"; 12import * as Popover from "@radix-ui/react-popover"; 13import { EditorState, TextSelection, Plugin } from "prosemirror-state"; 14import { EditorView } from "prosemirror-view"; 15import { Schema, MarkSpec, Mark } from "prosemirror-model"; 16import { baseKeymap } from "prosemirror-commands"; 17import { keymap } from "prosemirror-keymap"; 18import { history, undo, redo } from "prosemirror-history"; 19import { inputRules, InputRule } from "prosemirror-inputrules"; 20import { autolink } from "components/Blocks/TextBlock/autolink-plugin"; 21import { IOSBS } from "app/lish/[did]/[publication]/[rkey]/Interactions/Comments/CommentBox"; 22 23// Schema with only links, mentions, and hashtags marks 24const bskyPostSchema = new Schema({ 25 nodes: { 26 doc: { content: "block+" }, 27 paragraph: { 28 content: "inline*", 29 group: "block", 30 parseDOM: [{ tag: "p" }], 31 toDOM: () => ["p", 0] as const, 32 }, 33 text: { 34 group: "inline", 35 }, 36 }, 37 marks: { 38 link: { 39 attrs: { 40 href: {}, 41 }, 42 inclusive: false, 43 parseDOM: [ 44 { 45 tag: "a[href]", 46 getAttrs(dom: HTMLElement) { 47 return { 48 href: dom.getAttribute("href"), 49 }; 50 }, 51 }, 52 ], 53 toDOM(node) { 54 let { href } = node.attrs; 55 return ["a", { href, target: "_blank", class: "text-accent" }, 0]; 56 }, 57 } as MarkSpec, 58 mention: { 59 attrs: { 60 did: {}, 61 }, 62 inclusive: false, 63 parseDOM: [ 64 { 65 tag: "span.mention", 66 getAttrs(dom: HTMLElement) { 67 return { 68 did: dom.getAttribute("data-did"), 69 }; 70 }, 71 }, 72 ], 73 toDOM(node) { 74 let { did } = node.attrs; 75 return [ 76 "span", 77 { 78 class: "mention text-accent-contrast", 79 "data-did": did, 80 }, 81 0, 82 ]; 83 }, 84 } as MarkSpec, 85 hashtag: { 86 attrs: { 87 tag: {}, 88 }, 89 inclusive: false, 90 parseDOM: [ 91 { 92 tag: "span.hashtag", 93 getAttrs(dom: HTMLElement) { 94 return { 95 tag: dom.getAttribute("data-tag"), 96 }; 97 }, 98 }, 99 ], 100 toDOM(node) { 101 let { tag } = node.attrs; 102 return [ 103 "span", 104 { 105 class: "hashtag text-accent-contrast", 106 "data-tag": tag, 107 }, 108 0, 109 ]; 110 }, 111 } as MarkSpec, 112 }, 113}); 114 115// Input rule to automatically apply hashtag mark 116function createHashtagInputRule() { 117 return new InputRule(/#([\w]+)\s$/, (state, match, start, end) => { 118 const [fullMatch, tag] = match; 119 const tr = state.tr; 120 121 // Replace the matched text (including space) with just the hashtag and space 122 tr.replaceWith(start, end, [ 123 state.schema.text("#" + tag), 124 state.schema.text(" "), 125 ]); 126 127 // Apply hashtag mark to # and tag text only (not the space) 128 tr.addMark( 129 start, 130 start + tag.length + 1, 131 bskyPostSchema.marks.hashtag.create({ tag }), 132 ); 133 134 return tr; 135 }); 136} 137 138export function BlueskyPostEditorProsemirror(props: { 139 editorStateRef: React.MutableRefObject<EditorState | null>; 140 initialContent?: string; 141 onCharCountChange?: (count: number) => void; 142}) { 143 const mountRef = useRef<HTMLDivElement | null>(null); 144 const viewRef = useRef<EditorView | null>(null); 145 const [editorState, setEditorState] = useState<EditorState | null>(null); 146 const [mentionState, setMentionState] = useState<{ 147 active: boolean; 148 range: { from: number; to: number } | null; 149 selectedMention: { handle: string; did: string } | null; 150 }>({ active: false, range: null, selectedMention: null }); 151 152 const handleMentionSelect = useCallback( 153 ( 154 mention: { handle: string; did: string }, 155 range: { from: number; to: number }, 156 ) => { 157 if (!viewRef.current) return; 158 const view = viewRef.current; 159 const { from, to } = range; 160 const tr = view.state.tr; 161 162 // Delete the query text (keep the @) 163 tr.delete(from + 1, to); 164 165 // Insert the mention text after the @ 166 const mentionText = mention.handle; 167 tr.insertText(mentionText, from + 1); 168 169 // Apply mention mark to @ and handle 170 tr.addMark( 171 from, 172 from + 1 + mentionText.length, 173 bskyPostSchema.marks.mention.create({ did: mention.did }), 174 ); 175 176 // Add a space after the mention 177 tr.insertText(" ", from + 1 + mentionText.length); 178 179 view.dispatch(tr); 180 view.focus(); 181 }, 182 [], 183 ); 184 185 const mentionStateRef = useRef(mentionState); 186 mentionStateRef.current = mentionState; 187 188 useLayoutEffect(() => { 189 if (!mountRef.current) return; 190 191 const initialState = EditorState.create({ 192 schema: bskyPostSchema, 193 doc: props.initialContent 194 ? bskyPostSchema.nodeFromJSON({ 195 type: "doc", 196 content: props.initialContent.split("\n").map((line) => ({ 197 type: "paragraph", 198 content: line ? [{ type: "text", text: line }] : undefined, 199 })), 200 }) 201 : undefined, 202 plugins: [ 203 inputRules({ rules: [createHashtagInputRule()] }), 204 keymap({ 205 "Mod-z": undo, 206 "Mod-y": redo, 207 "Shift-Mod-z": redo, 208 Enter: (state, dispatch) => { 209 // Check if mention autocomplete is active 210 const currentMentionState = mentionStateRef.current; 211 if ( 212 currentMentionState.active && 213 currentMentionState.selectedMention && 214 currentMentionState.range 215 ) { 216 handleMentionSelect( 217 currentMentionState.selectedMention, 218 currentMentionState.range, 219 ); 220 return true; 221 } 222 // Otherwise let the default Enter behavior happen (new paragraph) 223 return false; 224 }, 225 }), 226 keymap(baseKeymap), 227 autolink({ 228 type: bskyPostSchema.marks.link, 229 shouldAutoLink: () => true, 230 defaultProtocol: "https", 231 }), 232 history(), 233 ], 234 }); 235 236 setEditorState(initialState); 237 props.editorStateRef.current = initialState; 238 239 const view = new EditorView( 240 { mount: mountRef.current }, 241 { 242 state: initialState, 243 dispatchTransaction(tr) { 244 const newState = view.state.apply(tr); 245 view.updateState(newState); 246 setEditorState(newState); 247 props.editorStateRef.current = newState; 248 props.onCharCountChange?.(newState.doc.textContent.length); 249 }, 250 }, 251 ); 252 253 viewRef.current = view; 254 255 return () => { 256 view.destroy(); 257 viewRef.current = null; 258 }; 259 }, [handleMentionSelect]); 260 261 return ( 262 <div className="relative w-full h-full group"> 263 {editorState && ( 264 <MentionAutocomplete 265 editorState={editorState} 266 view={viewRef} 267 onSelect={handleMentionSelect} 268 onMentionStateChange={(active, range, selectedMention) => { 269 setMentionState({ active, range, selectedMention }); 270 }} 271 /> 272 )} 273 {editorState?.doc.textContent.length === 0 && ( 274 <div className="italic text-tertiary absolute top-0 left-0 pointer-events-none"> 275 Write a post to share your writing! 276 </div> 277 )} 278 <div 279 ref={mountRef} 280 className="border-none outline-none whitespace-pre-wrap min-h-[80px] max-h-[200px] overflow-y-auto prose-sm" 281 style={{ 282 wordWrap: "break-word", 283 overflowWrap: "break-word", 284 }} 285 /> 286 <IOSBS view={viewRef} /> 287 </div> 288 ); 289} 290 291function MentionAutocomplete(props: { 292 editorState: EditorState; 293 view: React.RefObject<EditorView | null>; 294 onSelect: ( 295 mention: { handle: string; did: string }, 296 range: { from: number; to: number }, 297 ) => void; 298 onMentionStateChange: ( 299 active: boolean, 300 range: { from: number; to: number } | null, 301 selectedMention: { handle: string; did: string } | null, 302 ) => void; 303}) { 304 const [mentionQuery, setMentionQuery] = useState<string | null>(null); 305 const [mentionRange, setMentionRange] = useState<{ 306 from: number; 307 to: number; 308 } | null>(null); 309 const [mentionCoords, setMentionCoords] = useState<{ 310 top: number; 311 left: number; 312 } | null>(null); 313 314 const { suggestionIndex, setSuggestionIndex, suggestions } = 315 useMentionSuggestions(mentionQuery); 316 317 // Check for mention pattern whenever editor state changes 318 useEffect(() => { 319 const { $from } = props.editorState.selection; 320 const textBefore = $from.parent.textBetween( 321 Math.max(0, $from.parentOffset - 50), 322 $from.parentOffset, 323 null, 324 "\ufffc", 325 ); 326 327 // Look for @ followed by word characters before cursor 328 const match = textBefore.match(/@([\w.]*)$/); 329 330 if (match && props.view.current) { 331 const queryBefore = match[1]; 332 const from = $from.pos - queryBefore.length - 1; 333 334 // Get text after cursor to find the rest of the handle 335 const textAfter = $from.parent.textBetween( 336 $from.parentOffset, 337 Math.min($from.parent.content.size, $from.parentOffset + 50), 338 null, 339 "\ufffc", 340 ); 341 342 // Match word characters after cursor until space or end 343 const afterMatch = textAfter.match(/^([\w.]*)/); 344 const queryAfter = afterMatch ? afterMatch[1] : ""; 345 346 // Combine the full handle 347 const query = queryBefore + queryAfter; 348 const to = $from.pos + queryAfter.length; 349 350 setMentionQuery(query); 351 setMentionRange({ from, to }); 352 353 // Get coordinates for the autocomplete popup 354 const coords = props.view.current.coordsAtPos(from); 355 setMentionCoords({ 356 top: coords.bottom + window.scrollY, 357 left: coords.left + window.scrollX, 358 }); 359 setSuggestionIndex(0); 360 } else { 361 setMentionQuery(null); 362 setMentionRange(null); 363 setMentionCoords(null); 364 } 365 }, [props.editorState, props.view, setSuggestionIndex]); 366 367 // Update parent's mention state 368 useEffect(() => { 369 const active = mentionQuery !== null && suggestions.length > 0; 370 const selectedMention = 371 active && suggestions[suggestionIndex] 372 ? suggestions[suggestionIndex] 373 : null; 374 props.onMentionStateChange(active, mentionRange, selectedMention); 375 }, [mentionQuery, suggestions, suggestionIndex, mentionRange]); 376 377 // Handle keyboard navigation for arrow keys only 378 useEffect(() => { 379 if (!mentionQuery || !props.view.current) return; 380 381 const handleKeyDown = (e: KeyboardEvent) => { 382 if (suggestions.length === 0) return; 383 384 if (e.key === "ArrowUp") { 385 e.preventDefault(); 386 if (suggestionIndex > 0) { 387 setSuggestionIndex((i) => i - 1); 388 } 389 } else if (e.key === "ArrowDown") { 390 e.preventDefault(); 391 if (suggestionIndex < suggestions.length - 1) { 392 setSuggestionIndex((i) => i + 1); 393 } 394 } 395 }; 396 397 const dom = props.view.current.dom; 398 dom.addEventListener("keydown", handleKeyDown); 399 400 return () => { 401 dom.removeEventListener("keydown", handleKeyDown); 402 }; 403 }, [ 404 mentionQuery, 405 suggestions, 406 suggestionIndex, 407 props.view, 408 setSuggestionIndex, 409 ]); 410 411 if (!mentionCoords || suggestions.length === 0) return null; 412 413 // The styles in this component should match the Menu styles in components/Layout.tsx 414 return ( 415 <Popover.Root open> 416 {createPortal( 417 <Popover.Anchor 418 style={{ 419 top: mentionCoords.top, 420 left: mentionCoords.left, 421 position: "absolute", 422 }} 423 />, 424 document.body, 425 )} 426 <Popover.Portal> 427 <Popover.Content 428 side="bottom" 429 align="start" 430 sideOffset={4} 431 collisionPadding={20} 432 onOpenAutoFocus={(e) => e.preventDefault()} 433 className={`dropdownMenu z-20 bg-bg-page flex flex-col py-1 gap-0.5 border border-border rounded-md shadow-md`} 434 > 435 <ul className="list-none p-0 text-sm"> 436 {suggestions.map((result, index) => { 437 return ( 438 <div 439 className={` 440 MenuItem 441 font-bold z-10 py-1 px-3 442 text-left text-secondary 443 flex gap-2 444 ${index === suggestionIndex ? "bg-border-light data-[highlighted]:text-secondary" : ""} 445 hover:bg-border-light hover:text-secondary 446 outline-none 447 `} 448 key={result.did} 449 onClick={() => { 450 if (mentionRange) { 451 props.onSelect(result, mentionRange); 452 setMentionQuery(null); 453 setMentionRange(null); 454 setMentionCoords(null); 455 } 456 }} 457 onMouseDown={(e) => e.preventDefault()} 458 > 459 @{result.handle} 460 </div> 461 ); 462 })} 463 </ul> 464 </Popover.Content> 465 </Popover.Portal> 466 </Popover.Root> 467 ); 468} 469 470function useMentionSuggestions(query: string | null) { 471 const [suggestionIndex, setSuggestionIndex] = useState(0); 472 const [suggestions, setSuggestions] = useState< 473 { handle: string; did: string }[] 474 >([]); 475 476 useDebouncedEffect( 477 async () => { 478 if (!query) { 479 setSuggestions([]); 480 return; 481 } 482 483 const agent = new Agent("https://public.api.bsky.app"); 484 const result = await agent.searchActorsTypeahead({ 485 q: query, 486 limit: 8, 487 }); 488 setSuggestions( 489 result.data.actors.map((actor) => ({ 490 handle: actor.handle, 491 did: actor.did, 492 })), 493 ); 494 }, 495 300, 496 [query], 497 ); 498 499 useEffect(() => { 500 if (suggestionIndex > suggestions.length - 1) { 501 setSuggestionIndex(Math.max(0, suggestions.length - 1)); 502 } 503 }, [suggestionIndex, suggestions.length]); 504 505 return { 506 suggestions, 507 suggestionIndex, 508 setSuggestionIndex, 509 }; 510} 511 512/** 513 * Converts a ProseMirror editor state to Bluesky post facets. 514 * Extracts mentions, links, and hashtags from the editor state and returns them 515 * as an array of Bluesky richtext facets with proper byte positions. 516 */ 517export function editorStateToFacetedText( 518 state: EditorState, 519): [string, AppBskyRichtextFacet.Main[]] { 520 let fullText = ""; 521 let facets: AppBskyRichtextFacet.Main[] = []; 522 let byteOffset = 0; 523 524 // Iterate through each paragraph in the document 525 state.doc.forEach((paragraph) => { 526 if (paragraph.type.name !== "paragraph") return; 527 528 // Process each inline node in the paragraph 529 paragraph.forEach((node) => { 530 if (node.isText) { 531 const text = node.text || ""; 532 const unicodeString = new UnicodeString(text); 533 534 // If this text node has marks, create a facet 535 if (node.marks.length > 0) { 536 const facet: AppBskyRichtextFacet.Main = { 537 index: { 538 byteStart: byteOffset, 539 byteEnd: byteOffset + unicodeString.length, 540 }, 541 features: marksToFeatures(node.marks), 542 }; 543 544 if (facet.features.length > 0) { 545 facets.push(facet); 546 } 547 } 548 549 fullText += text; 550 byteOffset += unicodeString.length; 551 } 552 }); 553 554 // Add newline between paragraphs (except after the last one) 555 if (paragraph !== state.doc.lastChild) { 556 const newline = "\n"; 557 const unicodeNewline = new UnicodeString(newline); 558 fullText += newline; 559 byteOffset += unicodeNewline.length; 560 } 561 }); 562 563 return [fullText, facets]; 564} 565 566function marksToFeatures(marks: readonly Mark[]) { 567 const features: AppBskyRichtextFacet.Main["features"] = []; 568 569 for (const mark of marks) { 570 switch (mark.type.name) { 571 case "mention": { 572 features.push({ 573 $type: "app.bsky.richtext.facet#mention", 574 did: mark.attrs.did, 575 }); 576 break; 577 } 578 case "hashtag": { 579 features.push({ 580 $type: "app.bsky.richtext.facet#tag", 581 tag: mark.attrs.tag, 582 }); 583 break; 584 } 585 case "link": 586 features.push({ 587 $type: "app.bsky.richtext.facet#link", 588 uri: mark.attrs.href as string, 589 }); 590 break; 591 } 592 } 593 594 return features; 595}